Skip to content

Fix concurrency races on shared Http/Route singletons#251

Open
loks0n wants to merge 21 commits intomainfrom
fix/concurrency-shared-state
Open

Fix concurrency races on shared Http/Route singletons#251
loks0n wants to merge 21 commits intomainfrom
fix/concurrency-shared-state

Conversation

@loks0n
Copy link
Copy Markdown
Contributor

@loks0n loks0n commented Apr 27, 2026

Summary

Under the Swoole and SwooleCoroutine adapters, multiple requests run concurrently in the same PHP process and share a single Http instance plus the static Router / Route / Hook registries. Today, per-request data is written onto those shared objects — when a coroutine yields on I/O, another coroutine clobbers it. This PR removes four such mutations and replaces the one valid use case with a race-free equivalent.

Races fixed

  1. Http::$route — instance property holding "the current route." Two coroutines on the same Http overwrote each other; getRoute() and the telemetry attribution in run() saw the wrong route after any yield. Moved into the per-request context container (coroutine-local via Coroutine::getContext() in both Swoole adapters).
  2. Route::$matchedPathRouter::match() mutated the singleton Route stored in Router::$routes via setMatchedPath(). Concurrent hits on the same route through different aliases / wildcard segments raced, breaking path-param extraction in execute(). Router::match() now returns the matched route alongside the prepared-path string in an immutable value class; the singleton is no longer mutated.
  3. Http::$wildcardRoute — the wildcard branch did $route->path($path) on the singleton, so two concurrent wildcard requests fought over $path. Now we clone the wildcard route per request before stamping the URI.
  4. Hook::setParamValue() writeback in Http::getArguments() — every resolved param value was written back onto the shared Hook/Route. Removed. Replaced with the per-request RouteMatch::$arguments map (see below).

The RouteMatch value class

Routing state — the matched Route, the prepared-path string it was matched under, and the name-keyed argument map — is one logical thing, so this PR bundles it into a single value class:

final class RouteMatch
{
    public function __construct(
        public readonly Route $route,
        public readonly string $path,
        public array $arguments = [],
    ) {}
}

$route and $path are immutable. $arguments is mutable so the framework can fill it in once the action's params resolve, without rebuilding the RouteMatch. Safe because the RouteMatch lives on the per-request context container (coroutine-local under Swoole), so only the request's own coroutine touches it.

Lifecycle:

  • Router::match() returns ?RouteMatch (with empty $arguments).
  • Http::match() stores it on the per-request context container under 'match' (or null if no match).
  • Right before the route action runs, Http::execute() writes $arguments with the full resolved+validated argument map (path + query + body, post-validator) — same RouteMatch instance, no replacement on the context.
  • The wildcard branch builds a RouteMatch around the cloned wildcard route.
  • Telemetry attribution in run() reads it once at the end.

Note: $match->arguments is empty during init hooks — they run before the route's params are resolved. Surfacing path values into $arguments earlier (so init hooks can read URI segments like {databaseId} directly) is being tracked as a follow-up PR.

Terminology: resources vs. context

This PR formalizes a split that was already implicit in the codebase:

  • Http::setResource() / Http::getResource() — singletons on the global container. Same as before.
  • Http::setContext() (now public) — per-request values on the adapter's request container, which is coroutine-local under the Swoole adapters. Replaces the old setRequestResource() (protected, internal-only).

On the adapter, there is exactly one public reader:

  • Adapter::getContext(): Container — returns the container for the current execution context. Inside a request, that's the per-request container (coroutine-local under Swoole), with parent-chain fallback to the global one for singleton lookups. Outside a request (boot, onStart hooks), it's the global container directly.

The global container is constructor-injected into the adapter and not retrievable post-construction. There is no public getContainer() — callers don't need to know which scope they're in; getContext() returns the right one for where they are.

The framework writes the following keys on the context container:

Key When set What it holds
request / response Top of runInternal() Request / Response
error When an exception is caught (init/shutdown/runInternal/onStart) \Throwable
match Seeded to null at the top of runInternal(). Set to a RouteMatch once match() resolves; $arguments is filled with the resolved+validated set right before the action runs. The same RouteMatch instance is reused throughout the request. RouteMatch, or null (no route matched, or read before routing)

Inject match into any hook/action:

Http::shutdown()
    ->inject('match')
    ->action(function (RouteMatch $match) {
        // post-validation values, race-free under Swoole
        $template = $match->arguments['template'] ?? '';
    });

Breaking changes

This PR ships eight breaking changes. All are public-facing but localized.

1. Router::match() return type

Before: public static function match(string $method, string $path): ?Route
After: public static function match(string $method, string $path): ?RouteMatch

// Before
$route = Router::match('GET', '/users/123');
if ($route !== null) { /* ... */ }

// After
$match = Router::match('GET', '/users/123');
if ($match !== null) {
    $route = $match->route;
    $matchedPath = $match->path;
}

2. Http::getRoute() / Http::setRoute() removed

Both methods are gone. The current route is part of the RouteMatch value class on the context now.

  • Read: $http->getResource('match')?->route or inject 'match'.
  • Write: the framework sets the RouteMatch during match() / runInternal(); application code shouldn't.

3. Hook param values no longer populated by request handling

Http::getArguments() no longer calls $hook->setParamValue($key, $value). If you called $hook->getParamValue($key) or $hook->getParamsValues() from inside a route action / init / shutdown / error hook expecting it to return the resolved value for the current request, it will now return null.

Replacement: inject 'match' and read $match->arguments['key']. The framework populates this with the resolved+validated argument map right before the action runs, so shutdown / error hooks see the same values the action received. Race-free because the RouteMatch lives on the per-request context container (coroutine-local under Swoole), so only the request's own coroutine touches it.

// Before
$value = $hook->getParamValue('fileId');

// After (from a shutdown / error hook)
Http::shutdown()
    ->inject('match')
    ->action(function (RouteMatch $match) {
        $value = $match->arguments['fileId'] ?? null;
    });

init hooks see $match->arguments as empty — they run before the route's params are resolved. The full validated map is available from the action onward (and to all subsequent shutdown / error hooks). A follow-up PR will surface path values earlier so init hooks can read URI segments without falling back to Route::getPathValues().

4. Route::setMatchedPath() / Route::getMatchedPath() removed

These methods (and the underlying Route::$matchedPath property) have been deleted outright. Read it via $match->path.

5. Http::$requestContainer property removed

The protected ?Container $requestContainer = null; field was never assigned by the framework. Removed. If a subclass references it, drop the reference — per-request state lives on the adapter's context ($server->getContext()), which is coroutine-local in the Swoole adapters.

6. setRequestResource() renamed to setContext() (and now public)

Http::setRequestResource() (protected) is now Http::setContext() and is public — application code can use it to register per-request values, just as it already could with setResource() for singletons. Subclasses that overrode setRequestResource() need to rename their override (and widen visibility if they kept it protected). The Swoole adapters' coroutine-key constant REQUEST_CONTAINER_CONTEXT_KEY is likewise now CONTEXT_KEY, and the underlying string moved from '__utopia_http_request_container' to '__utopia_http_context'. If a subclass of either adapter referenced these, rename to match.

7. Adapter::getContainer() removed; replaced by getContext()

Adapter::getContainer() is gone. The adapter now exposes only getContext(): Container — a single public reader that returns the container for the current execution context (per-request inside a request with parent-fallback to global, global directly outside one). The global container stays constructor-injected and is not retrievable post-construction.

Custom Adapter subclasses must implement getContext() and drop their getContainer() implementation. Application code calling $server->getContainer() should switch to $server->getContext(); semantics match getContainer()'s old behavior (smart-routed by execution context), so a one-line rename is the whole migration.

8. Wildcard Route is now a per-request clone

The wildcard route inside RouteMatch for a wildcard handler is a per-request clone of Http::$wildcardRoute, not the singleton. Identity comparisons ($match->route === Http::$wildcardRoute) will fail. Property reads (getPath(), getAction(), etc.) behave identically.

Migration guide

For typical application code (defining routes, hooks, handlers), no changes are required unless you read the current route or used $hook->getParamValue(). The breaks only matter if you call the framework's routing primitives directly or subclass Http / the adapters.

If you do this… Change to…
$route = Router::match($m, $p); $match = Router::match($m, $p); $route = $match?->route;
$http->getRoute() from app code $http->getResource('match')?->route or ->inject('match') and read $match->route
$http->setRoute($route) from app code drop the call — set automatically during match() / runInternal()
$route->getMatchedPath() $match->path
$route->setMatchedPath(...) no replacement — Routes are no longer mutated per-request
$server->getContainer() (anywhere) $server->getContext() — same smart-routed behavior, just the new name
class MyAdapter extends Adapter { public function getContainer() {...} } implement getContext(): Container instead; drop the getContainer() override
class MyHttp extends Http { /* reads $this->requestContainer */ } use $this->server->getContext()
class MyHttp extends Http { protected function setRequestResource(...) } rename override to setContext(...)
Adapter subclass referencing REQUEST_CONTAINER_CONTEXT_KEY rename to CONTEXT_KEY
$hook->getParamValue('foo') inside a shutdown/error handler ->inject('match') and read $match->arguments['foo']
$route === Http::$wildcardRoute inside a wildcard handler compare on a stable property (e.g. $route->getMethod()), or check Http::$wildcardRoute !== null && $route->getAction() === Http::$wildcardRoute->getAction()

If you run on FPM only, everything keeps working identically — there is no concurrency in FPM, so the races never fire and the new code paths are equivalent to the old ones.

Test plan

  • vendor/bin/phpunit tests/HttpTest.php tests/RouterTest.php tests/RouteTest.php tests/RequestTest.php — 72/72 pass (added testShutdownHookCanInjectResolvedArguments covering the RouteMatch::$arguments flow).
  • vendor/bin/phpstan analyse — clean at level 7.
  • composer format:check — clean (Pint).
  • e2e suite (ResponseFPMTest, ResponseSwooleTest) requires the swoole / fpm Docker hosts from docker-compose.yml; unchanged from main locally — verify in CI.
  • Manual: hit a Swoole-backed deploy with concurrent requests against (a) the same parameterized route via different aliases, (b) the wildcard route from two clients in parallel, (c) a route whose handler awaits I/O and then reads getResource('match'), (d) a shutdown hook that injects 'match' and substitutes labels like {request.fileId} from $match->arguments.

🤖 Generated with Claude Code

loks0n and others added 2 commits April 27, 2026 15:00
Under the Swoole and SwooleCoroutine adapters multiple requests run
concurrently in the same process and shared a single Http instance plus
the static Router/Route/Hook registries. Per-request data was being
written onto those shared objects, so a coroutine yielding on I/O could
return to find another request had clobbered its routing state.

Four shared mutations are removed:

1. Http::$route — moved to the per-request container (coroutine-local
   under both Swoole adapters via Coroutine::getContext()), so getRoute()
   and the match() cache no longer collide between requests. Telemetry in
   run() now reads through getRoute().

2. Route::$matchedPath — Router::match() now returns the matched route
   together with the prepared-path string instead of mutating the
   singleton Route. Http::match() stashes the path on the request
   container; execute() reads it via getMatchedPath().

3. Http::$wildcardRoute — the wildcard branch now clones the singleton
   before stamping the request URI onto its $path, so two concurrent
   wildcard requests can't race on the shared route's path.

4. Hook::setParamValue() writeback in getArguments() — removed. The
   resolved $arguments array is still fed to the action; writing values
   back onto the shared Hook/Route was the only mutation that made
   shared hooks unsafe under concurrency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Container as @deprecated

Route::setMatchedPath() / Route::getMatchedPath() are no longer called by
the framework after Router::match() switched to returning the matched
path in its return tuple. Http::$requestContainer was never assigned —
per-request state lives on the adapter's container.

Left in place for backwards compatibility; flagged for removal in a
future major release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 27, 2026

k6 benchmark

Throughput Requests Fail rate p50 p95
5409 req/s 378733 0% 7.74 ms 15.53 ms

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

This PR fixes four concurrency races on shared Http/Route/Hook singletons under Swoole and SwooleCoroutine adapters by introducing a RouteMatch value class that carries per-request routing state (matched route, prepared-path key, resolved arguments) on the coroutine-local context container. The changes ship eight documented breaking changes — all localized to direct callers of routing primitives or Http/Adapter subclasses — and are a no-op under FPM.

Confidence Score: 4/5

Safe to merge for FPM deployments; Swoole callers should verify against the migration guide before deploying.

The concurrency fixes are correctly implemented and all 72 tests pass. The one nuance worth noting is that $match->arguments is populated by-reference inside getArguments() before per-param validation runs, so error hooks will see pre-validation (potentially invalid) candidate values for params that failed — this is intentional and explicitly tested in testErrorHookSeesPartialMatchArgumentsWhenValidationFails, but is subtly different from what the RouteMatch docblock implies. No new P0/P1 logic bugs were found beyond what was already captured in previous review threads.

src/Http/Http.php — the getArguments() by-reference population order (write before validate) and the execute() direct-call path (empty $match->path when no prior Router::match()).

Important Files Changed

Filename Overview
src/Http/RouteMatch.php New value class holding per-request routing state (matched Route, prepared-path key, resolved arguments); immutable route/path, mutable arguments populated by-reference during execute()
src/Http/Http.php Core request lifecycle refactored: route/match state moved to per-request context, getArguments() receives $match->arguments by reference to populate partial values before validation, setRequestResource renamed to public setContext()
src/Http/Router.php match() now returns ?RouteMatch instead of ?Route; removes per-request mutation of shared Route singleton (setMatchedPath replaced by RouteMatch value object)
src/Http/Route.php Removed $matchedPath property and setMatchedPath()/getMatchedPath() methods; path state now lives in RouteMatch value object
src/Http/Adapter.php Abstract getContainer() replaced by getContext(); returns per-request or global container depending on execution context
src/Http/Adapter/Swoole/Server.php REQUEST_CONTAINER_CONTEXT_KEY renamed to CONTEXT_KEY with updated key string; getContainer() renamed to getContext(); variable naming updated from $requestContainer to $context
src/Http/Adapter/SwooleCoroutine/Server.php Same CONTEXT_KEY renaming and getContainer()→getContext() change as Swoole/Server; finally-block cleanup updated accordingly
src/Http/Adapter/FPM/Server.php Trivial rename: getContainer() → getContext(), returns the same global $this->container
tests/HttpTest.php Added three new tests covering RouteMatch injection in shutdown hooks, partial-argument availability in error hooks on validation failure, and null-match in pre-routing error hooks; updated existing assertions from getRoute()/getContainer() to getResource('match')
tests/RouterTest.php Updated all Router::match() assertions to access ->route on the returned RouteMatch; switched from assertEquals to assertSame for identity checks; added ?-> null-safe guard on assertNotSame calls

Reviews (14): Last reviewed commit: "Populate \$match->arguments incrementall..." | Re-trigger Greptile

Comment thread tests/RouterTest.php Outdated
…iner

Removed instead of left as @deprecated — neither has been part of any
working code path since the previous commit, and keeping them around
just preserves footguns (the matched-path getter would silently return
'' on a route returned from Router::match).

Removed:
- Route::$matchedPath property
- Route::setMatchedPath() / Route::getMatchedPath()
- Http::$requestContainer property (never assigned)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Http/Route.php
loks0n and others added 6 commits April 28, 2026 10:23
The framework already exposes per-request resources under plain names
('request', 'response', 'error', 'server', 'route'), so the namespaced
sentinel constants were inconsistent. Drop them and use the same
convention. As a side effect, 'matchedPath' is now injectable into hooks
and actions like any other request resource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only the framework itself called setRoute (internal cache plumbing in
match() and the wildcard branch). Drop it from the public API and the
self-referential test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Singletons stay under setResource/getResource (the global container);
per-request values are now setContext (the request container). The
adapters' coroutine-context key and local variable rename to match:

- Http::setRequestResource() -> Http::setContext()
- Adapter\Swoole\Server::REQUEST_CONTAINER_CONTEXT_KEY
    -> Adapter\Swoole\Server::CONTEXT_KEY
- same in Adapter\SwooleCoroutine\Server
- $requestContainer locals -> $context
- internal storage key '__utopia_http_request_container'
    -> '__utopia_http_context'

setContext is protected, so this is internal-only — no application API
break. Subclasses overriding the old name need to rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Route and matched path are now pure context values — read them via
\$http->getResource('route') / 'matchedPath', or inject them into a
hook/action. Internal call sites that wrote them go through the
context container directly (or via setContext()).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pint catch from CI — sed introduced double quotes when replacing
\$http->getRoute() with \$http->getResource('route').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before, getContainer() did double duty: returning the request-scoped
container when inside a coroutine, the global container otherwise. That
made request-side reads/writes look like global ones (\$server->getContainer()
inside match()), which obscured the scope split.

Now:
- getContainer() always returns the global singleton container.
- getContext() returns the per-request context container (coroutine-local
  under Swoole, identical to the global one under FPM). Lookups still
  fall through to the global container's parent chain, so getContext()
  resolves singletons too.

Http internals updated to call getContext() for all per-request reads
and writes; the constructor's setup of \$this->container keeps using
getContainer() since it's grabbing the global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
loks0n and others added 9 commits April 28, 2026 14:58
The earlier removal of Hook::setParamValue writeback closed the
concurrency hole but stranded a real use case: shutdown hooks that
need the request's full resolved param map (post-validation, post-
coercion) to do label substitution like {request.fileId}.

Populate a name-keyed snapshot of the route's resolved arguments on
the per-request context as 'arguments', set right before the action
runs. Race-free (per-request context container, coroutine-local
under Swoole), no per-Hook mutation, and reuses the values
getArguments() already resolved — no validator double-invocation.

Available via ->inject('arguments') from shutdown / error hooks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The framework was juggling three separate context keys ('route',
'matchedPath', 'arguments') for state that's logically one thing:
"how this request was routed and what its action received." Replace
them with a single immutable RouteMatch value class set on the
context as 'match'.

Shape:
  final class RouteMatch {
      public function __construct(
          public readonly Route $route,
          public readonly string $matchedPath,
          public readonly array $arguments = [],
      ) {}
      public function withArguments(array $arguments): self;
  }

Lifecycle:
- Router::match() now returns ?RouteMatch (was ?array{Route, string})
- Http::match() stores it on the context as 'match' (or null on miss)
- Http::execute() reads it, calls withArguments() once the route's
  params are resolved, and re-stores. Synthesizes a RouteMatch if
  execute() is called directly without prior match() (test path).
- Wildcard branch in runInternal() builds a RouteMatch around the
  cloned wildcard route.
- Telemetry attribution reads it once at the end of run().

Inject via ->inject('match') from any hook/action; access ->route,
->matchedPath, ->arguments. The previous separate 'route' /
'matchedPath' / 'arguments' keys are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Since the property already lives on a class called RouteMatch, the
"matched" prefix is redundant. \$match->path reads cleaner than
\$match->matchedPath.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The helper had a single caller (Http::execute() updating the
arguments map after the action's params resolve) and the constructor
already accepts \$arguments. Inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously 'match' was only set after Http::match() ran (or by execute()
when called directly). Code that read it earlier — request hooks, the
global error path triggered by an exception in a request hook,
top-level introspection in app/http.php — got "Failed to find resource"
from getResource('match').

Set it to null up front, alongside 'request' and 'response', so
inject('match') and getResource('match') always return at least null
during a request. The real RouteMatch overwrites it once routing
resolves.

Added testMatchIsNullDuringEarlyErrorBeforeRouting covering the
"error in onRequest hook -> global error handler reads 'match'"
path that motivated this fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-phase population so init hooks can read URI segments via
\$match->arguments without falling back to Route::getPathValues():

- match()           : seeds \$arguments with the path values from
                      Route::getPathValues() — init hooks see them
- execute() (action): replaces \$arguments with the full
                      resolved+validated set (path + query + body)
                      right before the action runs — shutdown / error
                      hooks see the same map the action received

Also seeds path values when execute() is called directly without a
prior match() (test path).

Closes the gap that forced downstream init-hook code (e.g. Appwrite's
api/web request filters substituting databaseId/collectionId from
the URL) to keep calling Route::getPathValues() directly.

Added testInitHookSeesPathValuesInMatchArguments covering the
init -> match.arguments[pathParam] path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\$route and \$path stay readonly — they're genuinely immutable across a
request — but \$arguments is filled in two phases (path values at
match-time, full resolved set before the action) so making it mutable
saves us reconstructing the RouteMatch and re-pushing it onto the
context. Safe because the RouteMatch lives on the per-request context
container (coroutine-local under Swoole), so only the request's own
coroutine touches it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splitting that change out into its own PR. RouteMatch::\$arguments is
now empty during init hooks again; the validated set still lands in
\$arguments right before the action runs.

Reverts the relevant parts of a2c5636 ("Seed RouteMatch::\$arguments
with path values at match-time"). Keeps the mutability change from
6340582 since it's orthogonal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Adapter::getContainer() / getContext() split was a foot-gun: two
methods with the same return type, distinguishable only by docs.
Downstream code that called getContainer() expecting per-request
behavior silently got the global one after the rename, leading to
500-on-every-request bugs.

Reduce the public API surface:
- Adapter exposes only getContext(): smart-routed (request-scoped
  during a request, global otherwise), parent-chain falls through
  to the global for singleton lookups.
- The global container is constructor-injected and not retrievable
  post-construction. Adapters keep their own private reference.
- Http captures the global at construction via getContext() — the
  invariant being that Http is constructed at boot, never inside a
  request, so getContext() returns the global directly there.

Make Http::setContext() public so application code (not just the
framework) can register per-request values. Symmetric with the
already-public Http::setResource().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Http/Adapter.php Outdated
loks0n added a commit to appwrite/appwrite that referenced this pull request Apr 28, 2026
setContext() is now public on Http (utopia-php/http#251). The graphql
resolver no longer needs to reach into the per-request container
directly to swap the active match for nested resolution — it can just
ask Http to do it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
loks0n and others added 2 commits April 28, 2026 17:25
Earlier draft claimed \$arguments gets pre-populated with path values
at match-time so init hooks can read them. That seeding pass was
never merged (split out into a follow-up PR), so the docblock was
lying about behavior. Rewrite to describe what actually happens:
\$arguments is empty until execute() writes it once, just before the
route action runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most of the explanatory blocks I added are restating obvious behavior
or duplicating what the PR description covers. Reduce to one-liners
where intent isn't already clear from the code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/Http/Http.php Outdated
If a validator throws partway through param resolution (the normal
400-validation path), \$match->arguments would have been left empty
under the previous all-or-nothing assignment. Error hooks reading it
saw nothing.

Add a by-ref \$resolved out-param to getArguments() that gets written
*before* validate() runs for each param. The route call site binds
it to \$match->arguments, so a mid-loop throw leaves everything
resolved up to that point visible to downstream error hooks. Other
call sites omit the param.

Restores the partial-read behavior the old Hook::setParamValue
writeback gave us, race-free this time.

Added testErrorHookSeesPartialMatchArgumentsWhenValidationFails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant